Skip to content

chore: enforce hybrid PQ key exchange in mTLS handshakes - #922

Open
mkmks wants to merge 1 commit into
mainfrom
chore/pq-tls-handshake
Open

mkmks wants to merge 1 commit into
mainfrom
chore/pq-tls-handshake

Conversation

@mkmks

@mkmks mkmks commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Description

While rustls already supported PQ key change for a while, and we had enabled in mTLS between parties, we don't forbid explicitly classical key exchange which might lead to a downgrade by a malicious operator. This PR only permits hybrid PQ exchanges, preventing potential downgrades.

PR Checklist

Tick all that apply — by ticking I attest the item holds; justify any deviation in the description above.

  • Title follows conventional commits (e.g. chore: ...).
  • Tests added for every new pub item and test coverage has not decreased.
  • Public APIs and non-obvious logic documented; unfinished work marked TODO(#issue).
  • unwrap/expect/panic only in tests or for invariant bugs (documented if present).
  • No dependency version changes OR (if changed) only minimal required fixes.
  • No architectural protocol changes OR linked spec PR/issue provided.
  • No breaking deployment config / Helm chart / telemetry changes OR devops label + infra notified + review requested.
  • No breaking gRPC / serialized data changes OR commit marked with ! and affected teams notified.
  • No modifications to existing versionized structs OR backward compatibility tests updated.
  • No critical business logic / crypto changes OR ≥2 reviewers assigned.
  • No new sensitive data fields OR Zeroize + ZeroizeOnDrop implemented.
  • No new public storage data OR data is verifiable (signature / digest).
  • No unsafe; if unavoidable: minimal, justified, documented, and test/fuzz covered.
  • Strongly typed boundaries: typed inputs validated at the edge; no untyped values or errors cross modules.
  • Self-review completed.

@mkmks
mkmks requested a review from a team as a code owner October 5, 2026 10:34
@cla-bot cla-bot Bot added the cla-signed The CLA has been signed. label Oct 5, 2026

@titouantanguy titouantanguy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes do LGTM, but maybe others also have an opinion on what we want to support for the key exchange and cipher suite ?
cc @jot2re @dd23 @kc1212

Comment on lines +1076 to +1089
let error = server_result.unwrap_err();
assert!(
matches!(
error
.get_ref()
.and_then(|error| error.downcast_ref::<Error>()),
Some(Error::PeerIncompatible(
PeerIncompatible::NoKxGroupsInCommon
))
),
"unexpected rejection for {:?}: {error}",
group.name(),
);
assert!(client_result.is_err(), "client accepted {:?}", group.name());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, why an assert on the client's error but an unwrap on the server's one ?


/// Constructs mutually authenticated P2P TLS configurations with hybrid-only key exchange.
///
/// Both endpoints require TLS 1.3 and prefer [`kx_group::X25519MLKEM768`] over [`kx_group::SECP256R1MLKEM768`].

@titouantanguy titouantanguy Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why we support both ? and in that order ?
I reckon compliance frameworks might enforce SECP256R1MLKEM768 as that's what NIST suggests ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason was that rustls supports both, and whatever is the default came out first. Will pin the NIST recommendation, unless someone objects.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

x25519 is generally consider a safer choice than NIST, in particular when it comes to NIST's *R1 curves as there has been a bit of fear of weaknesses in the randomness.
So I think it depends on whether we want to stick to NIST recommendations or we rather trust the DJ Bernstein and co gang.
I do not have a strong opinion but for a personal project I would pick X25519. But I think we at some point agreed to go with NIST recommendations whenever possible?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, under further investigation X25519 is not FIPS approved. So we should probably go with secp256r1 as the default

where
R: ResolvesServerCert + ResolvesClientCert + 'static,
{
let mut provider = default_provider();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR talks about handshake only, but I'm wondering do we want to force AES 256 as well ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, will restrict session ciphers too.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this would be greater than the classical signature (128 bits) and PQ (192 bits). Still good but might be a bit overkill with current paramters

@jot2re jot2re left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for taking care of this.
I left a few smaller comments, but I also passed the branch through Claude that came with some surprisingly good findings:

  1. Nothing enforces the policy where the configs are used.
  • GrpcNetworkingManager::new and TlsIncoming in kms_impl.rs:758 accept any ClientConfig or ServerConfig. tests/security_mode.rs even passes a classical default config and expects success.
  • Any future caller can bypass the policy without anyone noticing.
  • Options: wrap the configs in a type (e.g. P2pTlsConfig) that only build_p2p_tls_config can create, or check crypto_provider().kx_groups when they are accepted.
  1. Signature verification uses a different provider from the handshake.
  • AttestedVerifier::new (tls.rs:198) and WebPkiClientVerifier::builder / WebPkiServerVerifier::builder in add_context take their algorithms from the global CryptoProvider::get_default(), not the restricted P2P provider.
  • That's harmless today, since signatures aren't restricted. But the P2P policy now lives in two places, and the verifier depends on whatever global provider is installed.
  • Suggest a single p2p_crypto_provider() passed into both the builder and the verifier (builder_with_provider). That also gives one place to narrow signature schemes later, or to add ML-DSA certificates.
  • Note that the docs say certificate authentication is still classical, so the PQ protection covers confidentiality only, not authentication.
  1. (minor) Sibling code doesn't follow the policy.
  • core/experiments/src/conf/party.rs:113 (client) and choreography/server.rs:61 (tonic ServerTlsConfig) still negotiate classical groups through the same GrpcNetworkingManager.
  • These are benchmark binaries, not production. But the docs say "P2P connections require hybrid", and benchmarks will measure smaller classical handshakes. Reusing the shared provider would fix both.

Comment thread ai-docs/ARCHITECTURE.md

The meta store is in-memory only, so a reboot of the core forgets every known request and session ID. The KMS connector keeps the state of each request in its [persistent database](https://github.com/zama-ai/fhevm/tree/main/kms-connector/connector-db); its [kms-worker](https://github.com/zama-ai/fhevm/tree/main/kms-connector/crates/kms-worker) marks a request as sent and only polls for the result on a retry, so a request is not run more often than necessary across core reboots.

P2P TLS requires TLS 1.3 with `X25519MLKEM768` or `secp256r1MLKEM768` key exchange, with X25519 first. The server and client use an explicit AWS-LC provider restricted to these hybrid groups. `threshold_networking::tls::build_p2p_tls_config` constructs both configurations, and `kms-server` supplies the attested verifier and certificate resolver. This policy applies to manual and automatic certificates. Classical-only and pure ML-KEM peers cannot connect. Certificate authentication uses classical signatures. Every peer must support at least one permitted hybrid group before deployment.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I could read from the NIST documents MLKEM768 provides level 3, equivalent to 192 bit AES, whereas secp256r1 (and by extension) x25519 provides around 128 bits security. So I guess we either want to use P384 or MLKEM512 to have the levels consistent.
However, we already used MLKEM1024 with P384, so we have had a tendency of selecting PQ parameters higher than their classical counterparts.


/// Constructs mutually authenticated P2P TLS configurations with hybrid-only key exchange.
///
/// Both endpoints require TLS 1.3 and prefer [`kx_group::X25519MLKEM768`] over [`kx_group::SECP256R1MLKEM768`].

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

x25519 is generally consider a safer choice than NIST, in particular when it comes to NIST's *R1 curves as there has been a bit of fear of weaknesses in the randomness.
So I think it depends on whether we want to stick to NIST recommendations or we rather trust the DJ Bernstein and co gang.
I do not have a strong opinion but for a personal project I would pick X25519. But I think we at some point agreed to go with NIST recommendations whenever possible?


/// Constructs mutually authenticated P2P TLS configurations with hybrid-only key exchange.
///
/// Both endpoints require TLS 1.3 and prefer [`kx_group::X25519MLKEM768`] over [`kx_group::SECP256R1MLKEM768`].

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, under further investigation X25519 is not FIPS approved. So we should probably go with secp256r1 as the default

where
R: ResolvesServerCert + ResolvesClientCert + 'static,
{
let mut provider = default_provider();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this would be greater than the classical signature (128 bits) and PQ (192 bits). Still good but might be a bit overkill with current paramters

}
}
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude pointed out that it would be a good idea to also add a test to validate the HelloRetryRequest path, to ensure that the next release can work gracefully with the 0.15 during a rolling upgrade. In theory it should work without issue, but would be good to have a test

cfg: ClientConfig::builder_with_provider(provider).with_protocol_versions(&[&TLS13])?,
}
.with_custom_certificate_verifier(verifier)
.with_client_cert_resolver(cert_resolver);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude pointed out that session resuming skips the attested verifier, and hence leaves a potential attack vector. It should be easy to solve with client.resumption = Resumption::disabled(), server.session_storage = Arc::new(NoServerSessionStorage {}) and server.send_tls13_tickets = 0.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed The CLA has been signed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants